Skip to content

fix(pm): recognise a population declared as a lowercase object property - #17113

Merged
os-trump merged 4 commits into
mainfrom
claude/issue-17057-doc-route-spelling-population-shape
Sep 9, 2026
Merged

fix(pm): recognise a population declared as a lowercase object property#17113
os-trump merged 4 commits into
mainfrom
claude/issue-17057-doc-route-spelling-population-shape

Conversation

@os-trump

@os-trump os-trump commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17057

The defect

scripts/pm/bare-root-worklist.mjs — the auditor whose job is to find undeclared
bare-root populations in repo gates — recognised a gate's declared population by
the name of the const holding it, uppercase only:

const POPULATION_CONSTANT = /^(?:[A-Z0-9_]*_ROOTS?|[A-Z0-9_]*_DIRS?|ROOTS|DIRS|POPULATION|[A-Z0-9_]*_SCOPE)$/;

scripts/check-doc-route-spelling.mjs declares its population as a lowercase
object property
instead — roots: ['content/docs', 'skills'] inside
const REAL_CONFIG = { … } — so its skills root was invisible to the very
instrument built to find it. Not a different defect; the same one, spelled
differently.

Both premises re-verified on origin/main 91f65c4ea before any edit.

What this PR does — fix (1), widen the recognition

A new POPULATION_PROPERTY mirrors POPULATION_CONSTANT term for term, with
the camelCase hump doing the work the literal underscore does there:

constant form property form example
[A-Z0-9_]*_ROOTS? [a-z][A-Za-z0-9]*Roots? scanRoots, docRoot
[A-Z0-9_]*_DIRS? [a-z][A-Za-z0-9]*Dirs? searchDirs
[A-Z0-9_]*_SCOPE [a-z][A-Za-z0-9]*Scope lintScope
ROOTS · DIRS roots · dirs bare, plural only
POPULATION population

The bare singular root / dir stay out, for the reason ROOT / DIR stay
out of the uppercase form and with more force at this casing: root is the
commonest property name in this tree for one repo-root path fragment, the exact
thing the restriction exists to exclude.

populationSpans now returns both shapes as one list, so no caller holds a
roster of them. The const scanner is byte-for-byte unchanged — the property
value is delimited by a separate depth-tracking scanner — so the before/after
below has exactly one variable in it.

Fix (2) (renaming the instance to const SCAN_ROOTS) was refused, per the
card's ruling: it makes today green and leaves the auditor's count silently
wrong in a direction no reader of its output can infer.

The measurement, and the positive control

origin/main 91f65c4ea, unmutated:  78 triples across 44 gate source files
after the widening:                81 triples across 46 gate source files

Exactly 3 newly-surfaced rows, 0 gone. Full row-set diff:

NEW   ⛔ UNTRIAGED  scripts/check-console-injection.mjs  distDir  packages
NEW   ⛔ UNTRIAGED  scripts/check-console-injection.mjs  specDir  packages
NEW   ⛔ UNTRIAGED  scripts/check-doc-route-spelling.mjs roots    skills
GONE  (none)

The positive control the card fixed in advance holds: under the widened
recognition the sweep re-finds check-doc-route-spelling.mjs's own roots:.
The instrument is not answering zero because it cannot answer yes. All three
rows are production config, none in a self-test fixture (maskSelfTests blanks
the fixture copy of the same literal at :787+). 3 ≤ 3, so this PR carries the
dispositions.

The PM's line-anchored grep lead (22 hits over scripts/**, one production
instance) under-counted by construction, as it said it would: it names one
production instance, the widened auditor finds three.

Dispositions for the three rows

1. check-doc-route-spelling.mjs roots skills → DECLARED, now REACHABLE.
The gate gains const ROOT_DIR_WATCH_HINTS = ['skills/**'], the idiom
check-corpus-claim-drift.mjs and check-doc-authoring.mjs already carry at
this same root for this same reasoncontent/docs carries a separator and
reaches the hint set on its own, skills does not. This closes the card's
second half: the family no longer sits Silent for a skills-only card.
Measured: of the 47 files tracked under skills/ on 91f65c4ea the walk admits
46 (every .md/.mdx at any depth, nothing skipped), so the declaration
over-names by exactly one .json file — 97.9% precise. Pinned to
REAL_CONFIG.roots in both directions by four new self-test cases in the
Walk wiring battery, so neither side can drift.

2 & 3. The two check-console-injection.mjs rows → UNJUDGED, with measured notes.
Both literals are join() path components at :940-:941
(path.join(ROOT, 'packages', 'console', 'dist') and …, 'packages', 'spec'));
the gate never walks that root — it opens an index file, a stamp, that dist's
own assets children (non-recursive) and the spec package's manifest and built
JS. ⛔ This PR does not write TRIAGE verdicts for them: UNJUDGED's own
docblock binds a recogniser-widening pass to record the measurement and leave
the judgement to the maintainer, which is exactly what #15468's widening did
before it. Recording them is what keeps --self-test honest rather than green.

The UNJUDGED shared base moved 66e68adc691f65c4ea. The bucket pins its
entries to ONE base, so a second widening cannot append without re-reading every
note already there — that cost is the pin's design, not an obstacle. Seven of the
eleven inherited entries name gate sources byte-identical across those two
commits (compared by blob id); of the two files that moved, the live-db-isolation
gate changed comment prose with no net line movement, and the doc-frontmatter
gate grew above the line its note cites — the one note whose text this pass
corrected (:436:446). No population boolean and no recorded reason was
otherwise rewritten.

Prove the change can fail — ablation on the exact instrument

Predicted before running: neutering POPULATION_PROPERTY alone must return the
sweep to the origin/main baseline and drop all three rows.

== HEAD blob: 86fb61af2f829cb9fc088ec721c8c84b44089ece
== anchor present before mutation (expect 1): 1
== original alternation gone after mutation (expect 0): 0
== injected marker on disk (expect 1): 1
== ablated report exit: 0
bare-root worklist: 78 (source file, constant, word) triple(s) across 44 gate source file(s) …
== doc-route-spelling roots rows under ablation (expect 0): 0
== console-injection rows under ablation (expect 0): 0
== RESTORE PROOF ==
-- git diff HEAD (expect no output):
blob now:  86fb61af2f829cb9fc088ec721c8c84b44089ece
blob HEAD: 86fb61af2f829cb9fc088ec721c8c84b44089ece
RESTORED: blob equality holds

78/44 under ablation is the pristine origin/main baseline to the row — and
it is measured with the new skills/** declaration still on disk, so the
declaration alone moves nothing here; the recogniser is what surfaces the rows.
Mutation and restore ran under trap … EXIT INT TERM with absolute paths;
restoration is proven by an empty git diff HEAD and blob equality against
the HEAD blob, never by an exit code.

⚠️ Reported because the discipline requires it: the first ablation attempt
was a silent no-op — a perl -0pi substitution that matched nothing and exited
0. The script's own on-disk landing check caught it and refused to publish a
reading; no number from that attempt was used. The run above is the re-anchored
one.

What was measured, and what was not

Derived gates — 33 derived / 33 run / 0 NOT-MEASURED / 0 UNRUN, reconciled by
the tool rather than by arithmetic over my own list:

✓ dispatch-gates --ran: 33 derived famil(ies) accounted for — 33 run, 0 NOT-MEASURED.

Derived with --repo objectstack-ai/objectstack (it holds against this
checkout's remote) on a tree merged up to origin/main f87fdf359, so no STALE
TREE caveat applies. Change set: exactly the two files below.

Also run, because the derivation flags their silence as evidence in neither
direction (their artifact roster sits under a directory one of my paths is in):
check-published-list-mirrors.mjs (+ --self-test), check:pm-label-desc-cap,
check:console-injection — all exit 0.

Repo-wide lint: run in full, no narrowing claimed. pnpm lint
(eslint . --no-inline-config) exit 0 in 83s at dc2cba197 (and 81s at 5e5df1e13 before the review fix). Scoped
--format json over the two changed files: 2 files, 0 errors, 0 warnings.

Control characters: pnpm check:nul-bytes green, plus a direct
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over both changed files — no match.

Not measured / not owed, stated rather than implied:

  • No test file in the tree names either edited script (git grep over
    *.test.ts/*.spec.ts is empty); their test suites are their --self-test
    entry points, both in the 33 above and both green.
  • The derivation's 48 artifact-roster families, 11 declared-wide families and the
    always-runs tail sit outside the derived total by construction. CI runs them.
  • The 13 "pending changeset" families do not apply: see below.

Changeset

skip-changeset, and the reason is read off the tree rather than asserted: the
whole diff is repo-local scripts/**, root package.json is private: true,
and no package's files array names scripts/ (checked across every workspace
manifest). The label is already applied to this PR.

Clause-②: no. No exported symbol was added to a published package, no key to
a published payload, and no packages/spec/src/** path is touched.

Review round 2 — a comment that claimed more than the regex delivers

The finding is real and reproduced independently. The comment beside the
property scan asserted that the leading character class kept both
a ? roots : dirs and a foo.roots: member expression from reading as a
declaration. Calling populationSpans directly:

input spans
const y = { a: cfg.roots }; 0 ✅ the member-expression half holds — . is not in the class
const x = a ? roots : dirs; 1 the claim is false — the ? is followed by a space and \s is in the class
const x = a?roots:dirs; 0 the tight spelling is the one excluded — the inversion of what a reader expects
roots: for (const a of b) { break roots; } 1 a label statement matches too
const CFG = { roots: ['skills'] }; 1 positive control — the recogniser fires

Taken: option 1 (correct the comment) plus the pins. The regex is NOT tightened.

One correction to the review's severity premise, measured rather than argued.
The review reasoned that a spurious span marks a region as declaring a
population, so a literal inside it "drops out of the worklist" — a false
negative, the swallowing direction. sweep does the opposite: it admits a
literal only when a span contains it (if (!span) continue), which makes a
span an inclusion filter. Measured three ways, by splicing synthetic families
over throwaway fixtures (removed under a trap; git status clean after):

bare root INSIDE spurious ternary span     rows=1 ["roots apps"]
bare root in NO span                       rows=0 []
legit const span + spurious ternary span   rows=1 ["SCAN_ROOTS apps"]

So a spurious span can only ADD a row — the loud direction, where the
UNJUDGED set-equality coupling forces someone to judge it — and can never hide
one; the third reading shows it cannot even steal a row's key, because the
constant half is pushed first and find takes the first match. The whole-tree
bound is unchanged and re-measured on this head: 3 surfaced, 0 gone.

That does not soften the finding — a comment that misdescribes its own regex is
exactly what this repo spent the morning deleting — but it does decide the fix.

Why not tighten. Excluding the ternary needs a look-behind past whitespace
for a ?; excluding the label form needs real parsing and cannot be done at
all. So a tightening would (a) be a second change to what this recogniser
matches, owing its own full row-set diff proving it surfaces nothing and hides
nothing — the ride-along propertyValueEnd's docblock refuses by name for the
const half — and (b) still leave the label form matching, so it would not even
retire the caveat. The comment now states the bound instead of promising a
guarantee.

Six new self-test cases pin every part of it so it cannot drift back: the
member-expression exclusion that does hold; the ternary match and its tight-
spelling inversion; the label match; and the admission direction in both
directions plus the span-order tie-break. ⛔ RECORDED, not accidents to be quietly tightened away is written beside them.

Re-verified on this head: --self-test green, row-set diff unchanged
(3 surfaced / 0 gone), 33 derived / 33 run / 0 NOT-MEASURED / 0 UNRUN,
pnpm lint green, scoped eslint 2 files / 0 errors / 0 warnings.

⚠️ Also worth recording: the first re-run of the gate batch produced six
exit 3 PREREQUISITE NOT MET
results, because the worktree had been torn down
and recreated without pnpm install. Those are NOT MEASURED, never passes —
pnpm install then a full re-run of all 33 is what the numbers above come from.

Files

  • scripts/pm/bare-root-worklist.mjs — the widening, its self-test pins, the
    three dispositions and the re-measured UNJUDGED base.
  • scripts/check-doc-route-spelling.mjs — the skills/** declaration and its
    four coupling pins.

Generated by Claude Code

`bare-root-worklist.mjs` recognised a gate's declared population by the NAME
of the `const` holding it, so a population spelled as an object property —
`roots: [...]` inside a config literal — could never match. The auditor built
to find undeclared bare-root populations was blind to one that was declared
in plain sight.

Measured on 91f65c4: the property shape adds exactly 3 rows, including the
one this card was filed from. Dispositions follow in the next commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@os-trump os-trump added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 9, 2026 — with Claude
@github-actions github-actions Bot added the size/s label Sep 9, 2026
`scripts/pm/bare-root-worklist.mjs` recognised a gate's declared population by
the NAME of the `const` holding it, uppercase only, so a population spelled as
an object property could never match. `check-doc-route-spelling.mjs` declares
`roots: ['content/docs', 'skills']` inside `const REAL_CONFIG`, so its `skills`
root was invisible to the very instrument built to find undeclared roots — not
a different defect, the same one spelled differently.

`POPULATION_PROPERTY` mirrors `POPULATION_CONSTANT` term for term, the
camelCase hump doing the work the literal underscore does there; the bare
singular `root`/`dir` stay out for the reason `ROOT`/`DIR` do.

Dispositions for the three rows it surfaces, measured on 91f65c4:
- check-doc-route-spelling.mjs declares `skills/**` (46 of 47 tracked files),
  the idiom two sibling doc gates already carry at this root; the row is now
  REACHABLE and the family stops being Silent for skills-only cards.
- the two check-console-injection.mjs rows are `join()` path components in a
  gate that never walks the root; recorded in UNJUDGED with measured notes,
  which is what a widening pass owes and all it may write.
- the UNJUDGED shared base moved 66e68ad -> 91f65c4, re-read rather than
  re-judged; one cited line number corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions github-actions Bot added size/m and removed size/s labels Sep 9, 2026
Review finding on #17113: the comment beside the property scan claimed the
leading character class kept BOTH `a ? roots : dirs` and a `foo.roots:` member
expression from reading as a declaration. Only the second is true — the `?` is
followed by a space and `\s` is in the class, so the ternary matches while the
tight `a?roots:dirs` does not, the inversion of what a reader expects. A
`roots:` label statement matches too.

The comment now states that as a measurement, and the severity is stated with
it rather than assumed: `sweep` admits a literal only when a span CONTAINS it,
so a span is an inclusion filter and a spurious one can only ADD a row, never
hide one. Measured three ways, and bounded on the whole tree by this widening's
own row-set diff (3 surfaced, 0 gone, unchanged by this commit).

Six new self-test cases pin all of it: the member-expression exclusion that
holds, the ternary and label matches recorded as known rather than accidental,
and the admission direction in both directions plus the span-order tie-break.

⛔ The regex is NOT tightened. That is a second change to what this recogniser
matches and would owe its own full row-set diff — the ride-along this file
refuses by name for the `const` half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@os-trump
os-trump marked this pull request as ready for review September 9, 2026 11:03
@os-trump
os-trump enabled auto-merge September 9, 2026 11:03
@os-trump
os-trump added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 9c8b497 Sep 9, 2026
38 checks passed
@os-trump
os-trump deleted the claude/issue-17057-doc-route-spelling-population-shape branch September 9, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants